React: 二重サブミット防止はuseRefフラグではなくUI gateで
TL;DR
- 「処理中は再実行をブロックしたい」を
useRefフラグで実装するのはアンチパターン。 - ボタン経由なら
useState+disabled、formなら React 19 のuseActionState、任意の非同期処理ならuseTransitionのisPendingで十分。 useRefが本当に必要なのはdisabledで interceptできない場面(global keydown 等)に限られる。
遭遇した問題
送信ボタン連打で二重サブミットしたくない。useState(isSubmitting) で書くと:
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (isSubmitting) return; // ❌ 古い値を読んでる可能性
setIsSubmitting(true); // ❌ 次のrenderで反映、同じevent loop tickでは効かない
await submitOrder();
setIsSubmitting(false);
};
連打されたとき isSubmitting がまだ false のままで素通りしてしまう。そこで「即座に変更でき、読み取りも同期的なref」に手が伸びる:
const isSubmittingRef = useRef(false);
const handleSubmit = async () => {
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
try {
await submitOrder();
} finally {
isSubmittingRef.current = false;
}
};
これだと送信中の見た目フィードバック(disabledやスピナー)を出すために別途stateも必要になり、refとstateの二重管理になる。
原因
「mutableフラグで同期的にブロックする」という発想自体が、命令型で考えているときの発想。Reactの宣言モデルに直すと、「ブロック」は「UI状態としてgate」に置き換わり、レース自体が起きなくなる。
ボタンに disabled={isSubmitting} を付けると、setIsSubmitting(true) の後Reactが再renderしてDOMの disabled 属性が更新されるまで、ブラウザは同じmacrotask内で次のclickイベントを発火できない。ボタン自体が物理的なgateとして機能するので、useStateの非同期更新が問題にならない。
解決
通常のボタン → useState + disabled
// OrderForm.tsx
export function OrderForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await submitOrder();
} finally {
setIsSubmitting(false);
}
};
return (
<button disabled={isSubmitting} onClick={handleSubmit}>
{isSubmitting ? '送信中…' : '注文確定'}
</button>
);
}
form submit → React 19 の useActionState
isPending フラグを自動で返してくれるので、手動loading flagが不要:
// CreateUserForm.tsx
export function CreateUserForm() {
const [error, submitAction, isPending] = useActionState(
async (_prev: string | null, formData: FormData) => {
try {
await createUser({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
return null;
} catch (e) {
return (e as Error).message;
}
},
null,
);
return (
<form action={submitAction}>
<input name="name" placeholder="名前" />
<input name="email" type="email" placeholder="メール" />
<button type="submit" disabled={isPending}>
{isPending ? '作成中…' : '作成'}
</button>
{error && <p>{error}</p>}
</form>
);
}
form以外の非同期処理 → useTransition
// RefreshButton.tsx
export function RefreshButton() {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
await refreshDashboard();
});
};
return (
<button disabled={isPending} onClick={handleClick}>
{isPending ? '更新中…' : '更新'}
</button>
);
}
useRef が正当な唯一のケース → disabled でinterceptできないイベント
windowレベルのキーイベント等、UIでgateできない場面に限り、refフラグが妥当:
// useConfirmOnEnter.ts
export function useConfirmOnEnter(onConfirm: () => Promise<void>) {
const isProcessingRef = useRef(false);
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
if (e.key !== 'Enter' || isProcessingRef.current) return;
isProcessingRef.current = true;
try {
await onConfirm();
} finally {
isProcessingRef.current = false;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onConfirm]);
}
このケースでも、まず疑うべきは「処理を冪等にできないか」「直前の処理対象との値比較で再入防止できないか」「inputのEnterで blur() を呼ばせて focusoutハンドラに集約できないか」。フラグでブロックは最終手段。
判断フロー
- UIで
disabledにできるか? →useState+disabled - formか? →
useActionState - 任意の非同期処理か? →
useTransition - 処理を冪等にできるか / 値比較で済むか? → そうする
- globalイベント等でどれにも当てはまらないか? → ここで初めて
useRef
まとめ
「ブロックしたい」と感じたら、まずUI gateで置き換えられないか問う。ref フラグは disabled でinterceptできない最終手段。